fix(native): narrow a var()-valued font-family to its first family - #417
Open
YevheniiKotyrlo wants to merge 10 commits into
Open
fix(native): narrow a var()-valued font-family to its first family#417YevheniiKotyrlo wants to merge 10 commits into
YevheniiKotyrlo wants to merge 10 commits into
Conversation
React Native's `fontFamily` takes ONE family, not a stack. Written out, the
compiler already narrows a family list to its first entry. Through a `var()`
it did not: the whole stack was delivered as an array, and the text rendered
in the platform default instead of the requested face.
.a { font-family: "Helvetica Neue", Arial, sans-serif; } /* narrows */
.b { --f: "Helvetica Neue", Arial, sans-serif;
font-family: var(--f); } /* whole stack */
The narrowing now happens where the value is applied, so both spellings
deliver the same single family. The walk is recursive because a resolved
variable nests its comma groups.
Tests: 5, covering a literal stack, a var()-valued stack, a nested resolution,
a single family (unchanged), and a non-array value (unchanged).
The runtime reduction in objects.ts rests on a compile-time claim nothing tested: that a literal stack is already narrowed to its first family, and that a var()-valued one is not, because it never reaches parseFontFamily. Both are now pinned. The var fixture declares its property twice on purpose — a single-definition variable is inlined at compile time and would be narrowed after all, which is what makes the runtime path unreachable from a naive test.
…ed array A style function's head is `Record<never, never>` - a plain object with no keys - but the test only asked whether index 0 was `typeof "object"` with no own keys. Two shapes that occur in a resolved style descriptor slip through: - `[[], "Arial"]` reports true, because `Object.keys([])` is empty too. A nested font stack whose first group is empty is read as a function call. - `[null, "Arial"]` throws `Cannot convert undefined or null to object`, because `typeof null` is `"object"`. Excluding arrays and null first is what `isStyleDescriptorArray` already does one function up. The parameter widens to `unknown`: the body was always a total runtime check, and the callers that need it most are holding a value off the wire.
React Native's `fontFamily` is one family name, never a stack, and three
compiler paths produce it. Two narrowed - `parseFontFamily` and the `font`
shorthand, both by taking `[0]`. The third did not.
A `font-family` LightningCSS cannot type falls to `parseUnparsed`, which
returns the token list as it found it, and `addDescriptor` stores it as a
static style. Six spellings of plain CSS reach it, none of them exotic:
font-family: Inter, Helvetica,; -> fontFamily: ["Inter","Helvetica"]
font-family: ,Inter, Helvetica; -> fontFamily: ["Inter","Helvetica"]
font-family: Inter,,Helvetica; -> fontFamily: ["Inter","Helvetica"]
font-family: "Inter", "Helvetica",; -> fontFamily: ["Inter","Helvetica"]
font-family: 12, Inter; -> fontFamily: [12,"Inter"]
font-family: ,; -> fontFamily: []
A keyframe takes the same path, so `@keyframes` carried the array too. None of
it can be caught downstream: `applyDeclarations` copies a static style onto the
props with `Object.assign`, never through `applyValue`.
The reduction now exists once, in `src/utilities/font-family.ts`, and the three
producers read it. It is flatten-then-first-usable rather than take-the-first:
a nested group is read in place, and an entry that cannot name a family - a
number, a null, an empty group - is skipped, the way a browser skips a family
it cannot use. A stack with nothing usable emits no declaration at all, so a
family set by a lower-specificity rule survives the cascade instead of being
overwritten with `[]`.
The one answer the compiler cannot give is `deferred`: the first usable entry
is a `var()`, whose value only exists at render. That descriptor is emitted
whole and reduced again at render. A `var()` standing BEHIND a literal is
narrowed away here, because React Native can never reach it - which also drops
the declaration's reactivity, since its value can no longer change.
…does
The compiler narrows every stack it can read. The one it cannot is the value
behind a `var()`, and `applyValue` is the first place on that path where the
property name and the resolved value are both in hand.
It now runs the same reduction, so the two planes cannot disagree, and three
shapes the previous first-then-descend loop got wrong are covered:
- `[[], "Arial"]` returned `undefined`. The descent walked into the empty
first group and never came back for the sibling.
- `[null, "Arial"]` set a raw `null` on `fontFamily`, ten lines below the
comment explaining that null means "set to undefined" in React Native.
- `--n: 12; font-family: var(--n)` set `fontFamily: 12`, a number on a
property React Native types `string`.
Nothing usable now leaves the key absent rather than clearing it, matching
what `applyValue` already means by `undefined`: the declaration failed, so
whatever an earlier rule set stands.
The guard excludes plain objects because `applyDeclarations` parks
`{ [prop]: true }` on the target while a delayed value resolves and reclaims
it by identity. Reducing that marker away would strand every `var()`-valued
font-family unresolved.
typography.test.tsx had blocks for Font Size, Smoothing, Style, Weight, Variant Numeric and Letter Spacing, and none for Font Family. The two override cases are the end-to-end proof of the runtime reduction: a theme variable with a single definition is inlined and narrowed by the compiler, so only a SECOND definition puts a stack in front of the runtime. They also record what the default theme actually produces. `font-sans` is `ui-sans-serif` - a CSS generic no typeface is registered under on either platform - so narrowing makes the value type-correct without changing what is drawn. It is the overridden `--font-sans` that reaches a real face.
`calc()` in the head of a font stack is a style function too, so the compiler defers it and the runtime reduces what it resolved to. It resolves to a number, which is skipped for the same reason `12` is skipped at compile time.
This was referenced Aug 15, 2026
…operly I measured every test in this branch against `upstream/main` with the source reverted, which is the only thing that separates a test that guards the fix from one that passes either way. Five of the seven the PR originally shipped were green on `main`; the current tree is 31 red of 45. The result is that the passengers are now labelled CONTROL where they earn their place, and the native plane covers the cases only it can answer. Native plane, 11 new cases. Every one of them is a `var()` route, which is the half no compiler assertion reaches: the descriptor is identical whatever the variable holds, so only a render says which family React Native is handed. var(--missing, Helvetica) -> Helvetica var(--missing, Inter, Helvetica) -> Inter var(--a, var(--b, serif)) -> serif var(--a, var(--b, serif)) with --b set -> Georgia var(--missing, Arial), var(--f) -> Arial --f: "Helvetica Neue", Arial -> Helvetica Neue --f: "Foo, Bar", Arial -> Foo, Bar --f: 12, Arial / --f: unset, Arial -> Arial provider [[], "Arial"] -> Arial provider [undefined, "Arial"] -> Arial On `main` each of those hands React Native the array instead: `["Inter", "Helvetica"]`, `["Helvetica Neue","Arial"]`, `["Foo, Bar","Arial"]`, and so on. Compiler plane, 4 new cases: the quoted and multi-ident spellings on both the typed and the unparsed path, so the two paths are pinned to agree on what one family is; and the deferred descriptor for each fallback shape, which is what says the compiler plane cannot answer those and the render must. One known limit, measured rather than assumed. `reduceParseUnparsed` stores a space-separated ident group and a comma-separated stack in the same array, so `--f: Helvetica Neue` and `--f: Inter, Helvetica` both compile to `["f", ["<string>", "<string>"]]`. Nothing downstream can separate them, and the reduction reads both as a stack, so the first renders as `Helvetica`. Quoting the name keeps it a single string and it renders whole. Both halves are pinned, on the plane that can see each: the identical compiled value on the compiler side, the resulting family on the native side. Joining a multi-token group in `reduceParseUnparsed` — the change that would lift the limit — reddens exactly those two and nothing else. Two controls say out loud that they cannot fail for the reason they look like they test. The typed-path block guards a refactor no input distinguishes: restoring `return stack[0]` inside `firstFontFamily` reddens nothing anywhere. The null-head case cannot be delivered by a render at all — `StyleDescriptor` has no null member, so writing it fails `tsc`, and `resolveValue`'s own `isDescriptorArray` would resolve the stack away before `applyValue` saw it. Mutation-proved, one broken thing at a time: loosening `isStyleFunction` 8 red, descending into the head instead of flattening 8, dropping the deferred branch 22, removing the unparsed narrowing 11, removing the runtime reduction 19, reducing the delayed-style marker 15, clearing the key on nothing-usable 2, applying the reduction to every property 13, joining a space group 2.
Three comment corrections, no behaviour change.
`applyValue`'s nothing-usable branch claimed a family an earlier rule set
survives the cascade. Measured, that holds only on the compile-time path,
where no descriptor is emitted at all: `.b { font-family: Georgia }` then
`.a { font-family: ,; }` keeps `Georgia` here and yields `[]` on main. On
the resolved `var()` path `applyDeclarations` deletes the key before it
resolves, so the same pair with `var(--n)` over `--n: 12` gives `{}` here
and `{ fontFamily: 12 }` on main - better either way, but Georgia is gone
in both. The comment now names which path it is claiming.
`isDelayedMarker`'s null exclusion is unreachable from its one call site,
which turns null into undefined and then excludes undefined. It stays,
because the predicate answers a question about a value rather than about
that caller's ordering, and `typeof null === "object"` is the same trap
being fixed in `isStyleFunction` here. Saying so keeps the next reader
from having to work out whether it is load-bearing.
The Tailwind Font Family block said both `--font-sans` overrides reach a
real face. Only `:root` does, resolving `Georgia`; `.dark` is not active
and resolves `ui-sans-serif`, the same generic as the four controls. Both
still bind, because on main that generic arrives as a seven-entry array.
`isStyleFunction` excludes a null head because `typeof null` is `"object"` and `Object.keys(null)` throws. `isStyleDescriptorArray`, six lines above it in the same file, asks the same question from the other side and carried the same untreated `typeof value[0] === "object"`. The consequence is quieter than the throw its sibling had, which is why it survived: a null head sends it into the branch that demands an array, so it answers `false` for a value that IS a descriptor array. Null is not a function head — it is a value, a hole the compiler left where an operand could not be parsed, and it reaches a native runtime as `null` rather than `undefined` because the sheet goes through `JSON.stringify` on the way. The predicate is exported and read at ten call sites across `dimension`, `filters`, `transform-functions`, `box-shadow`, `_expand` and `variables`, so the misclassification is not local to one caller. Fixing one copy and leaving the other made this change a partial one. Both are now the same shape, for the same stated reason.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
React Native's
fontFamilytakes one family, not a stack. Three compiler paths produce it and only two narrowed:parseFontFamily— the typed path, tookvalue[0].parseFont— thefontshorthand, tookvalue.family[0].parseUnparsed— everything LightningCSS cannot type, returned the token list as it found it.That third path is not exotic. Every
var()-valuedfont-familygoes down it, which is how Tailwind emitsfont-sans/font-serif/font-mono, and so does plain CSS the parser rejects:@keyframestakes the same path, so an animation carried the array too.TextStyle.fontFamilyis astring, so Fabric refuses every one of them: no font change, no warning, the element renders in the platform default.None of it can be caught downstream —
applyDeclarationscopies a static style onto the props withObject.assign, never throughapplyValue.Those three are the producers of a
font-family-keyed value. There is a fourth route that produces afont-keyed one, which neither plane narrows and this PR does not change; it is the last entry under Known limits.Fix
The reduction exists once, in
src/utilities/font-family.ts, and the three producers read it. It is flatten-then-first-usable rather than take-the-first: a nested group is read in place, and an entry that cannot name a family — a number, a null, an empty group — is skipped, the way a browser skips a family it cannot use. A stack with nothing usable emits no declaration at all, so a family set by a lower-specificity rule survives the cascade instead of being overwritten with[].The reduction has no opinion about generic keywords, and that is deliberate.
font-family: sans-serif, Internarrows to"sans-serif", because the first entry is usable as a family name and a browser resolves it the same way — a generic is always available, so nothing behind it is ever reached. Measured identical onmainand this branch; it is stated here only because it was nowhere stated before.The one answer the compiler cannot give is
deferred: the first usable entry is avar(), whose value only exists at render. That descriptor is emitted whole and reduced again inapplyValue, the first place on that path where the property name and the resolved value are both in hand. Avar()standing behind a literal is narrowed away at compile time, because React Native can never reach it — which also drops the declaration's reactivity, since its value can no longer change.One nuance on that runtime half, measured rather than assumed. When a resolved value yields nothing usable,
applyValuedeclines to set the key — but it cannot preserve a family an earlier rule put there, becauseapplyDeclarationsrunsdelete target[prop]before it resolves. Under.b { font-family: Georgia }followed by.a { font-family: var(--n) }over--n: 12, this branch produces{}andmainproduces{ fontFamily: 12 }: better either way, since12is a value React Native refuses, butGeorgiais gone in both. The cascade genuinely does survive on the compile-time path, where no descriptor is emitted at all — the same pair written.a { font-family: ,; }keepsGeorgiaon this branch and yields[]onmain. The source comment at that branch says which of the two it is claiming.Two supporting fixes fall out:
isStyleFunctionasked only whether index 0 wastypeof "object"with no own keys, so[[], "Arial"]read as a function call (Object.keys([])is empty too) and[null, "Arial"]threwCannot convert undefined or null to object. Both shapes occur in a resolved stack.applyValue's previous first-then-descend loop set a rawnullonfontFamilyten lines below the comment explaining that null means "set to undefined", and setfontFamily: 12for--n: 12; font-family: var(--n).Values, before and after
Measured by rendering each stylesheet through
registerCSS+renderon this branch and onmain:main--f: Inter, Helvetica; font-family: var(--f)["Inter","Helvetica"]"Inter"font-family: var(--missing, Inter, Helvetica)["Inter","Helvetica"]"Inter"--f: "Helvetica Neue", Arial; font-family: var(--f)["Helvetica Neue","Arial"]"Helvetica Neue"--f: "Foo, Bar", Arial; font-family: var(--f)["Foo, Bar","Arial"]"Foo, Bar"font-family: Inter, Helvetica,;["Inter","Helvetica"]"Inter"font-family: var(--missing), Helvetica["Helvetica"]"Helvetica"vars({ "--stack": ["Inter","Helvetica"] })["Inter","Helvetica"]"Inter"<VariableContextProvider value={{ "--stack": [[], "Arial"] }} />[[],"Arial"]"Arial"Every
maincell is a value React Native refuses.Tests
62 cases across four planes — the reduction on its own, the compiler,
applyValue, and a real render. 41 of them fail onupstream/mainwith this branch's source reverted; 21 pass, and each one says in a comment why it is a deliberate control rather than a guard. I measured that per test rather than assuming it: the seven tests this PR opened with turned out to be five controls and two guards, which is what prompted the rest of this work.The measurement is reproducible: put all five source files back to
f70c402byte-exactly, keep every test, and run the six files this PR touches. That reportsTests: 41 failed, 117 passed, 158 totalwithnumRuntimeErrorTestSuites0 — no suite fails to load. The 158 is the six files in full; 96 of them aretypography.test.tsx's pre-existing utilities, which leaves the 62 this PR adds.main__tests__/utilities/font-family.test.ts__tests__/utilities/style-descriptor.test.ts__tests__/compiler/font-family.test.tsapplyValue__tests__/native/font-family-stack.test.ts__tests__/native/font-family.test.tsx__tests__/vendor/tailwind/typography.test.tsx(new block)Nine of the 41 are the new unit's own file, which fails with
narrowFontFamily is not a functiononcesrc/utilities/font-family.tsis gone. That is what a missing module looks like rather than a value difference, so the honest figure is 32 of the 62 red for a real difference in what React Native is handed, and 9 more that exist only because the unit does.The native plane carries the cases no compiler assertion can reach. Every
var()spelling below compiles to the same deferred descriptor whatever the variable holds, so only a render says which family lands:var()holding a multi-family stackvar()with a literal fallback in its own parentheses, and one whose fallback is itself a stackvar(--a, var(--b, serif)), both with and without--bdefined12,unset, an empty group)<VariableContextProvider />, and its update on rerenderEvery
var()fixture declares its variable twice on purpose. A single-definition variable is inlined at compile time and narrowed there, which makes the runtime path unreachable from a naively written fixture.src/__tests__/vendor/tailwind/typography.test.tsxgains the Font Family block the file was missing. The four default-theme cases are controls —ui-sans-serifis a CSS generic no typeface is registered under, so narrowing makes the value type-correct without changing what is drawn. Both overridden---font-sanscases bind, and only one of them reaches a real family: the:rootoverride resolvesGeorgia, while the.darkoverride is not active and resolves the theme's ownui-sans-serif— the same generic as the four controls, with no face behind it either. It binds anyway, because a second definition defeats the inliner and onmainthat generic arrives as the whole seven-entry stack rather than as a string.Mutation-proved, one broken thing at a time, counting reddened cases across the six test files this PR touches:
isStyleFunctionstops excluding an array head and a null head[0]instead of flatteningreduceParseUnparsedjoins a space group (lifts the first known limit below)parseFontFamilygoes back toreturn stack[0]parseFontgoes back tovalue.family[0]The in-scope column understates one row. Unscoping the runtime reduction from
fontFamilyreddens 13 cases in these six files but 134 across 19 files repo-wide, because every array-valued property then goes through the reduction —box-shadow,filter,transform,safe-areaand the rest. That whole-repo figure is the one worth reading; theprop === "fontFamily"guard is load-bearing far outside this feature.The last two rows are reported rather than hidden, and they are the same finding twice. Both typed producers hand the reduction a
string[]:parseFontFamilyavalueandparseFontavalue.family. For any array of stringsnarrowFontFamilyreturns the first element when there is one and nothing when there is not, which is exactly what[0]returns — so the two spellings are equivalent on every input the type permits and no test can distinguish them. I checked the second one repo-wide as well as in scope, and it reddens nothing there either. Both blocks guard the paths staying attached to the shared reduction, not a behaviour change, and their comments say exactly that.Known limits
An unquoted multi-word family name behind a
var()loses its tail.reduceParseUnparsedgroups an unparsed value by comma and nests a multi-token group, so forfont-familya one-entry stack of two idents and a two-entry stack of one ident each collapse onto the identical array:Nothing downstream can separate them, so the reduction reads both as a stack and
--f: Helvetica Neuerenders asHelvetica. Quoting the name keeps it a single string (["f", "Helvetica Neue"]) and it renders whole, which is CSS's own answer for a family name that is not one ident. The typed path is unaffected — LightningCSS joins the idents there, and this PR pins that both paths agree on the quoted spelling.I left it rather than picking a reading: joining a nested group instead would fix this case and break
var(--x, A, B), C, whose fallback nests the same way and does mean a comma list. A real fix separates the two groupings inreduceParseUnparsed, which is a shared parser change touching every property. Both halves of the limit are pinned — the identical compiled value on the compiler plane, the resulting family on the native plane — and the mutation table's space-group row shows that lifting it reddens exactly those two and nothing else.vars()cannot express a font stack undertsc, though it now works at runtime. The behaviour half is closed:vars({ "--stack": ["Inter","Helvetica"] })rendersfontFamily: "Inter"on this branch and hands React Native the array onmain. The types are the part that is still shut, on the deprecated API, in two independent places:react-native-css/runtimetype-resolves tosrc/runtime.ts→src/web/api.tsx, whosevarsisRecord<string, string | number>. A stack isTS2322: Type 'string[]' is not assignable to type 'string | number'. Jest resolves the same specifier tosrc/runtime.native.tsand runs the native function, so the mismatch is invisible at runtime.vars()return type is not assignable to astyleprop at all:TS2769 … Type '{ [VAR_SYMBOL]: string; } & { [k: string]: StyleDescriptor; }' is not assignable to type 'StyleProp<ViewStyle>'.Both are types-only defects on
vars()itself rather than anything this change touches, so the equivalent runtime coverage is written through<VariableContextProvider />— the APIvars()is deprecated in favour of — which typechecks and exercises the identical path.A null head cannot reach the reduction through a render.
applyValuehandles[null, "Arial"]and a unit test pins it, butStyleDescriptorhas no null member so the value does not compile, andresolveValue's ownisDescriptorArrayreads a null head as a style-function call (typeof null === "object") and resolves the stack away beforeapplyValuesees it. That predicate is the same defectisStyleFunctionhad, on a shared path used by every property; fixing it there is a separate change. The native-plane test states the measurement and asserts the head the type system does allow.font: 12px var(--stack)is narrowed by neither plane. Thefontshorthand with avar()inside it cannot be typed by LightningCSS either, so it takes the unparsed path — but withproperty === "font", which the compiler's narrowing misses because that is keyed onfont-family, and whichapplyValuemisses because its guard isprop === "fontFamily". Measured identical onmainand on this branch:React Native has no
fontstyle prop, so nothing consumes that value on either side and this is pre-existing rather than a regression. I am naming it because it is a fourth route to the same shape, and the framing at the top — three producers, two of which narrowed — does not account for it. Closing it properly means the unparsed path understanding thatfontdecomposes intofont-familyand friends, rather than a second property name added to two guards; that is a larger change than this one and I would rather not smuggle it in here.One thing to flag
src/utilities/index.tsgainsexport * from "./font-family", and./utilitiesis an already-published subpath, sonarrowFontFamilyand theFontFamilyNarrowingunion become supported API on merge. Nothing marks them internal. That matches howisStyleFunctionandSpecificityare already exported from the same subpath, so I have assumed it is intended — say the word and I will move the module somewhere unexported instead. Relatedly,isStyleFunction's parameter widens fromStyleDescriptortounknown: source-compatible for every caller, but it does change the exported.d.tsand removes some compile-time pressure on them.Second commit — the sibling predicate had the same hole
isStyleFunctionis not alone in that file.isStyleDescriptorArraysits six lines above it, asks the same question from the other side — is this a list of VALUES rather than a function to evaluate — and carried the identical untreatedtypeof value[0] === "object".Its failure is quieter than the throw, which is why it outlived it.
typeof nullis"object", so a null head sends it into the branch that demands an array, and it answersfalsefor a value that IS a descriptor array:Null is not a function head. It is a value — a hole left where an operand could not be parsed — and it reaches a native runtime as
nullrather thanundefinedbecause the sheet goes throughJSON.stringifyon the way. The predicate is exported and read at ten call sites acrossdimension,filters,transform-functions,box-shadow,_expandandvariables, so the misclassification is not local to one caller.Both functions are now the same shape for the same stated reason, and
src/__tests__/utilities/style-descriptor.test.tsgains adescribefor the sibling mirroring the one it already had: three controls that pass before and after, and the null case that does not.Fixing one copy and leaving its twin would have made this pull request a partial fix of one defect.
Verification
Windows, warm cache, run twice with identical results:
Zero suite-load failures, verified from
--jsonrather than from the summary line:numRuntimeErrorTestSuites0,numTotalTests1138, and the two failing suites account for all 3 failures inside their own assertions. Those three aresrc/__tests__/babel/{react-native,react-native-web}.test.ts, a pre-existing Windows-onlybabel-plugin-testeroutput mismatch over an unrewritten relativerequire("../View"); they fail identically on pristinemainon the same machine.yarn typecheckandyarn lintboth exit 0.